Skip to content

feat: timeline.export() for NLE project bundles - #102

Open
videodb-kal wants to merge 6 commits into
video-db:mainfrom
videodb-kal:feat/nle-export-client
Open

feat: timeline.export() for NLE project bundles#102
videodb-kal wants to merge 6 commits into
video-db:mainfrom
videodb-kal:feat/nle-export-client

Conversation

@videodb-kal

@videodb-kal videodb-kal commented Jul 31, 2026

Copy link
Copy Markdown

timeline.export() — NLE project bundles

A timeline can already be rendered to a video. This adds the other thing you might want from it: an editable project.

job = timeline.export()          # returns immediately
job.wait()                       # or poll job.refresh()
url = job.download_url()         # short-lived link to a .zip

The zip holds an FCP7 XML project, an OTIO copy, an EDL, an SRT and the media the sequence references — importable with media already relinked, rather than a flat video.

+521 lines, 0 deletions. One new module, one method, one export from the package root.


The shape

sequenceDiagram
    participant U as Your code
    participant T as Timeline
    participant J as ExportJob
    participant A as VideoDB

    U->>T: timeline.export()
    T->>A: POST (the same payload generate_stream sends)
    A-->>T: job id + status
    T-->>U: ExportJob
    U->>J: wait()
    loop until terminal
        J->>A: GET status
    end
    U->>J: download_url()
    J->>A: GET artifact
    A-->>J: a short-lived URL
Loading

Submit returns straight away because the work is minutes of downloading and encoding. Nothing is held open.

Timeline.export()

def export(
    self,
    format: str = "nle",
    name: str = None,
    client_ref: str = None,
    timeline_id: str = None,
) -> ExportJob
Parameter Why it exists
format Named rather than assumed, so a second format is additive
name The sequence name in the NLE's project panel. Falls back to the timeline id — an unnamed sequence is a worse import than an ugly one
client_ref Your own identifier, echoed back on the job, so you never have to hold ours to correlate
timeline_id Your identifier for this timeline; assigned for you when omitted

The payload is the same shape generate_stream sends, including the fallback that uploads the timeline JSON when it exceeds MAX_PAYLOAD_SIZE — a long timeline posted inline can exceed the request body limit.

ExportJobvideodb/export.py

Member Notes
refresh() One read; returns self so it chains
wait(timeout=..., poll_interval=...) Blocks to a terminal state
done / failed / terminal Properties, not string comparisons at the call site
download_url() Minted per call and short-lived by construction — never stored, so a link cannot quietly expire in your database
fidelity What the bundle carried, approximated and dropped

ExportJob is exported at the package root, so from videodb import ExportJob works for type annotations without reaching into a submodule.


Design decisions, each with a test

A job is read back under the timeline that produced it. The read path is scoped by timeline, not global — a bare job id is not a lookup key.

download_url() returns str, and now actually does. It was annotated str while returning the response dict on one branch.

Absent and null are different. name, client_ref and timeline_id are omitted from the payload when unset rather than sent as null — absent means "fall back to the timeline id", null means "there is no name", and those are not the same request.

The docstrings describe the SDK's behaviour, not the service behind it. An earlier draft explained infrastructure this package has no business knowing about.

Compatibility

Purely additive: one new module, one new method, one new package-level export. No existing signature, return type or behaviour changes. Nothing is deprecated.

Requires

A VideoDB deployment where the export feature is enabled. Against one where it is not, timeline.export() raises the same error any unavailable endpoint does.

This PR is not a prerequisite for the feature working — it is a typed convenience over an endpoint that can also be called directly. It can merge before, after, or independently of the rest.


Verification

tests/test_export.py — 255 lines, no network. Covers the payload shape including the large-timeline upload fallback, the omitted-versus-null distinction, terminal-state handling, wait() timeout behaviour, and download_url()'s return type.

Known follow-up

fidelity is exposed on ExportJob but the status response does not currently populate it, so it reads as empty even for an export that dropped content. The field is right; the plumbing behind it is not finished.

🤖 Generated with Claude Code


Review pass (2026-08-11, 3fd76d2)

  • Failures now raise RequestTimeoutError / InvalidRequestError — the same VideodbError hierarchy GenerationJob uses, so a documented except VideodbError catches both job types.
  • Timeline.export's return annotation resolves under typing.get_type_hints (real top-level import; no cycle exists).
  • Submit accepts id as well as job_id, matching GenerationJob.from_data; .job_id alias added; connection attribute is _connection like every peer; paths compose from ApiPath.export.
  • README example gained a failure branch, and the fidelity paragraph now points at the bundle's own fidelity.md report.

29 tests green.

videodb-kal and others added 6 commits July 30, 2026 11:11
An export produces an editable Premiere project — FCP7 XML, OTIO, EDL, captions
and the media the sequence references — rather than a rendered video. The work is
minutes of downloads and encoding, so export() submits and returns an ExportJob
immediately; the caller polls or calls wait().

The shape is not invented. It mirrors Timeline.generate_stream, which is how this
SDK already asks the platform to do something with a timeline: serialize
to_json(), POST it inline under `editor`, and fall back to uploading the JSON when
it exceeds MAX_PAYLOAD_SIZE. Export is the same question as render — here is a
timeline, produce an artifact — so it is the same shape.

Mirroring rather than inventing is what makes the payload-size fallback come
along for free. These requests cross a gateway with a hard body cap, so a long
timeline posted inline fails at the edge with nothing useful in the response. Had
this been designed from scratch it would have been found the first time somebody
exported a feature-length timeline.

Three decisions worth stating, each with a test:

- download_url() is a method, not a property. What it returns is a signed URL with
  a short life; a property invites caching, and a cached signed URL works in
  testing and 403s a day later. Minted per call, never held on the job.
- done and failed are both False for a status this client does not recognise. The
  platform's vocabulary can grow, and reporting an unknown status as done would
  have a caller fetch an artifact that is not there. Waiting on a status we cannot
  interpret is the recoverable mistake.
- wait() raises TimeoutError rather than returning a still-running job. A caller
  handed an unfinished job by a method named wait will treat it as finished.

Optional fields are omitted rather than sent as null: absent means "fall back to
the timeline id", null means "there is no name", and those are different answers.
A submit response with no job_id raises instead of yielding a job that cannot be
refreshed, waited on or downloaded.

ExportJob lives in videodb/export.py rather than editor.py, which is already 1,200
lines — and it keeps this off the lines the in-flight quality branch touches.

17 tests against a stub connection: no network, no platform, no credentials.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
refresh() and download_url() addressed a job by id alone. The platform scopes an
export read by timeline, so every one of them 404'd against a real server — which
is what the first live call found, and what no stub could have.

ExportJob now carries timeline_id and builds its own path from it. That is not
cosmetic: scoping the read by timeline is what lets the platform answer 404 for
another user's job id instead of leaking it.

A job built from a response with no timeline_id raises a sentence saying exactly
that, rather than composing a malformed path and reporting whatever 404 comes
back.

Verified live: submit, refresh, and wait() polling a real export through to done.

20 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
Two gaps in the public surface, both found by asking what a user of this package
actually sees.

ExportJob was importable only as `videodb.export.ExportJob`. Every other job-like
class — GenerationJob, Sandbox, VoiceClone, CaptureSession — is exported from the
package root and listed in __all__. GenerationJob is the direct precedent, and a
job class that needs a submodule path when its siblings do not is the kind of
inconsistency people work around rather than report.

README documented Timeline and generate_stream but not export(), which is new
public API. The example sits next to the timeline it belongs to and shows the
whole shape: submit, wait, download, and read the fidelity summary — including
why that last one matters, since an export that succeeds while dropping the
user's colour grades is not a plain success and a client that cannot see it will
report it as one.

The download URL is called out as not-to-be-cached in the example itself. It is
signed and short-lived, and a cached one works in testing and 403s a day later.

24 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
This is a public repository, and three comments described backend internals a
published SDK has no business referencing: the terminal-status list said it
"mirrors the export service's vocabulary", the wait timeout said it "matches the
export service's own per-job budget", and both editor.py and a test named the
API gateway and its body cap as the reason for the upload fallback.

None of it is wrong, and none of it belongs here. A reader of this package
cannot see those systems, cannot depend on them, and should not learn their
shape from a docstring. Each is now stated in terms of what the SDK does and
why a caller should care: half an hour is longer than an export is expected to
take, and a long timeline is uploaded because it can exceed the request body
limit.

No behaviour change; comments and docstrings only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
The method is annotated -> str and could return None when the response carried
no URL. A caller reasonably treats the result as a string, so None surfaces
wherever it is next handed — an opener, an HTTP call, a log line reading
"None" — by which point nothing points back at the export that had no bundle.

It now raises, naming the job and the likely cause.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CwxN3uj28RUVbPVYVnYztw
…tion, document what ships

- ExportJob raised builtin TimeoutError/ValueError where its sibling
  GenerationJob raises RequestTimeoutError/InvalidRequestError; a
  documented `except VideodbError` catch-all handled one job type's
  timeout and crashed on the other's. Public surface — the types have
  to be right at first release, so they are now the package's own.
- Timeline.export's `-> "ExportJob"` had no import in scope:
  typing.get_type_hints raised NameError for every annotation resolver.
  videodb.export imports nothing from editor, so the import is real,
  top-level, and the late local import is gone.
- job_from_response accepts `id` as well as `job_id`, the same pair
  GenerationJob.from_data accepts — a submit answering the other
  spelling must not hard-fail after the job was created.
- `.job_id` alias to match GenerationJob; the connection attribute is
  `_connection` like every peer.
- Paths compose from ApiPath.export instead of a literal.
- README: the example now has a failure branch (wait() returns on
  failure), and the fidelity paragraph points at the bundle's own
  report instead of promising a field the status response does not
  populate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018hU2Vo3r5DYZVFiowNaVZK
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant